package org.haptimap.hcimodules.guiding;

import java.util.HashMap;

import org.haptimap.hcimodules.HCIModule;
import org.haptimap.hcimodules.util.WayPoint;

import android.Manifest.permission;
import android.app.Activity;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.location.Location;
import android.media.AudioManager;
import android.media.MediaPlayer;
import android.media.SoundPool;
import android.media.SoundPool.OnLoadCompleteListener;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.util.Log;

/**
 * <p>This {@link HCIModule} produces sound according to the angle the user is currently pointing at, using
 * a {@link WayPoint} as a reference. The {@link WayPoint} might be added with the constructor or at a later 
 * occasion with one of the setNextDestination(...) methods.</p>
 * 
 * The available constructors in this class are:
 * <blockquote>
 * <pre>
 * AudioGuide(Context context)
 * AudioGuide(Context context, String destinationName, String latitude, String longitude)
 * AudioGuide(Context context, WayPoint destination)
 * </pre>
 * </blockquote>
 * 
 * <p>While interacting with this module, the current deviation from the target will be processed and the 
 * tone mapped to the deviation will be played. This sound will be played in a continuously default rate and 
 * since these sound streams are normally very small, the default rate should be enough for the interaction. 
 * The default rate can be override using {@link #setRateInterval(int)} and it can be restored to the default 
 * value using {@link #setDefaultRateInterval()}.</p>
 * 
 * <p>This {@link HCIModule} reminds of the {@link HapticGuide} module, with the main difference that 
 * it does not use support distances for rate intervals.</p>
 * 
 * <p> The different zones for the audio feedback are emitted according to the following angles:</p>
 * 
 * <blockquote><pre>
 * AHEAD: 		if deviation &lt |15| degrees
 * ZONE ONE: 	if |15| &lt deviation &lt |25| degrees
 * ZONE TWO: 	if |25| &lt deviation &lt |35| degrees
 * ZONE THREE: 	if |35| &lt deviation &lt |45| degrees
 * ZONE FOUR: 	if |45| &lt deviation &lt |55| degrees
 * ZONE FIVE: 	if |55| &lt deviation &lt |65| degrees
 * ZONE SIX:	if |65| &lt deviation &lt |75| degrees
 * BEHIND: 		if |75| &lt deviation &lt |180| 
 * </pre></blockquote>
 * 
 * <p>The current angle for the max deviation is <strong>+(-)75 degrees</strong> and within this range 
 * the module will give feedback with different tones. If the user points to an angle that exceeds a 
 * <strong>+(-) 75 degrees</strong>, only one singular tone will be emitted.</p>
 * 
 * <p>The class can also be used together with an {@link AudioGuideEventListener}, which will
 * be called on different events. </p>
 * 
 * <p>When a destination is reached, besides a broadcast which can be received with a 
 * {@link BroadcastReceiver} and registered with the action {@link Guide#ACTION_DESTINATION_REACHED},
 * the AudioGuide will also call {@link AudioGuideEventListener#onDestinationReached()} event and a 
 * destination tune will be played. When the destination tune is played, the module will then go into 
 * a pause state.</p>
 * 
 * <p>The module follows a state machine, which can be compared to the regular Android approach. The
 * states that are included are: {@link #onStart()} -> {@link #onResume()} -> {@link #onPause()} -> 
 * {@link #onStop()} and {@link #onDestroy()}</p>
 * 
 * <p>For further details about the different states, please read the {@link HCIModule}.</p> 
 * 
 * <p>The simplest way in using this module is by <strong>creating</strong> this class with the 
 * {@link #AudioGuide(Context, WayPoint)} constructor with the <strong>first destination</strong> to be 
 * guided to. The interaction does not begin until the device gets a GPS fix. Once the <strong>destination 
 * is reached</strong> the {@link AudioGuideEventListener} calls the 
 * {@link AudioGuideEventListener#onDestinationReached()} and <strong>a destination tune will be played</strong>.<p> 
 * 
 * <p>Once you reach your destination and the module is in a pause state, you can continue the interaction by
 * adding the next destination. The module will automatically resume and no {@link #onResume()} is necessary.
 * The next destination might be added using one of the following methods:
 * 
 * <blockquote><pre>
 * {@link #setNextDestination(WayPoint)}
 * {@link #setNextDestination(String, double[])}
 * {@link #setNextDestination(String, android.location.Location)}
 * {@link #setNextDestination(String, String, String)}
 * </pre></blockquote>
 *  
 * <p>This module <strong>must</strong> have the following permissions in the AndroidManifest.xml file:</p>
 * <blockquote><pre> 
 * {@link permission#ACCESS_FINE_LOCATION}</li>
 * </pre></blockquote>	
 * 
 * @see HCIModule
 * @see AudioGuideEventListener
 * 
 * @author Miguel Molina, August 2011
 */
public class AudioGuide extends Guide {	

	/**
	 * The singular value for this class. It's used to differentiate the log messages
	 * from other classes.  
	 */
	private static final String TAG = "AudioGuide";

	/**
	 * Boolean used to output log messages. If false, no other messages but error messages will
	 * appear on the DDMS console (Logcat).
	 */
	private static final boolean DEBUG = false;

	/**
	 * These constants are the limits in degrees from the destination/target
	 */
	private static final int AHEAD_DEVIATION_LIMIT = 15;
	private static final int OUTSIDE_GEIGER_LIMIT = 75;
	private static final int ZONE_ONE_LIMIT = 25;
	private static final int ZONE_TWO_LIMIT = 35;
	private static final int ZONE_THREE_LIMIT = 45;
	private static final int ZONE_FOUR_LIMIT = 55;
	private static final int ZONE_FIVE_LIMIT = 65;
	private static final int ZONE_SIX_LIMIT = 75;

	//Values and mapping for the Soundpool.
	private SoundPool mSoundPool;	
	private HashMap<SoundID, Integer> mSoundPoolMap;	
	private SoundID mCurrentState;
	private boolean soundPoolReady;
	private int currentStreamID;

	/**
	 * The number of concurrent streams that could be played by the SoundPool
	 */
	private static final int MAX_STREAMS = 3;

	//MediaPlayer values.
	private MediaPlayer mMediaPlayer;
	private boolean mediaPlayerReady;

	//AudioManager is used to retrieve/set volume values.
	private AudioManager mAudioManager;
	private float mActualVolume;
	private float mMaxVolume;
	private float mVolume;

	/**
	 * The AudioGuideEventListener called on different events.
	 */
	private AudioGuideEventListener mAudioGuideEventListener;

	/**
	 * The internal Event handler which is used together with the listener, 
	 * if the listener has been registered.
	 */
	private EventHandler mEventHandler;	

	/**
	 * Enum used to map the different tones with angles. These values are used 
	 * to find the right sound file which can be played by the SoundPool.
	 */
	private enum SoundID{		
		AHEAD_STATE,
		BACKSIDE_STATE,
		ZONE_ONE_STATE,
		ZONE_TWO_STATE,
		ZONE_THREE_STATE,
		ZONE_FOUR_STATE,
		ZONE_FIVE_STATE,
		ZONE_SIX_STATE,
	}

	/**
	 * Default constructor used to initiate this {@link HCIModule}. The context expected is the one 
	 * obtained by using {@link Activity#getApplicationContext()} If other {@link Context} is used, the 
	 * module might not operate if moving from the current {@link Activity} from where it has been created.
	 * 
	 * @param context The application's context 
	 */
	public AudioGuide(Context context){
		super(context);
		initLocalValues();
		Log.i(TAG, "AudioGuide created");
	}

	/**
	 * Default constructor used to initiate this {@link HCIModule}. The context expected is the one 
	 * obtained by using {@link Activity#getApplicationContext()} If other {@link Context} is used, the 
	 * module might not operate if moving from the current {@link Activity} from where it has been created.
	 * 
	 * @param context The application's context
	 * @param destination A {@link WayPoint} representing the destination
	 */
	public AudioGuide(Context context, WayPoint destination){
		super(context, destination);
		initLocalValues();
		this.onStart();
	}

	/**
	 * Default constructor used to initiate this {@link HCIModule}. The context expected is the one 
	 * obtained by using {@link Activity#getApplicationContext()} If other {@link Context} is used, the 
	 * module might not operate if moving from the current {@link Activity} from where it has been created.
	 * 
	 * @param context The application's context
	 * @param destinationName The destination's name 
	 * @param latitude The destination's latitude in WGS84 decimal format
	 * @param longitude The destination's longitude in WGS84 decimal format
	 */
	public AudioGuide(Context context, String destinationName, String latitude, String longitude){
		super(context, destinationName, latitude, longitude);
		initLocalValues();
		this.onStart();
	}

	@Override
	public void onStart() {
		super.onStart();
		setInitialDeviceSound();
	}

	@Override
	public void onResume() {
		super.onResume();
		resumeSoundPool();
	}	

	@Override
	public void onPause() {
		super.onPause();
		pauseSoundPool();
	}

	@Override
	public void onStop() {
		super.onStop();
		stopSoundPool();
	}

	/**
	 * Mandatory method to be called when the class is not longer needed. 
	 * All native resources in use will be released.  
	 */
	@Override
	public void onDestroy() {
		super.onDestroy();
		destroySoundPool();
		destroyMediaPlayer();
	}

	/**
	 * Starts some local 
	 */
	private void initLocalValues(){
		this.mActualVolume = 0f;
		this.mMaxVolume = 0f;
		this.mVolume = 0f;
		this.mAudioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
		this.mSoundPool = new SoundPool(MAX_STREAMS, AudioManager.STREAM_MUSIC, 0);
		this.mSoundPoolMap = new HashMap<SoundID, Integer>();	
		this.mediaPlayerReady = initMediaPlayer();
		initSoundPoolMap();
		initEventHandler();
	}

	/**
	 * Release the resources since the mediaplayer will not longer be used.
	 */
	private void destroyMediaPlayer() {
		if(mMediaPlayer != null){
			mMediaPlayer.release();
		}
	}

	/**
	 * Pause the SoundPool's active streams and wait to resume.
	 */
	private void pauseSoundPool() {
		if(mSoundPool != null){
			mSoundPool.autoPause();
		}
	}

	/**
	 * Resumes the SoundPool from previous state.
	 */
	private void resumeSoundPool() {
		if(mSoundPool != null){
			mSoundPool.autoResume();
		}
	}	

	private void stopSoundPool(){
		if(mSoundPool != null){
			mSoundPool.stop(currentStreamID);
		}
	}
	/**
	 * Release memory resources since they will not longer be needed once this module is 
	 * destroyed.
	 */
	private void destroySoundPool() {
		if(mSoundPool != null){
			mSoundPool.release();
			mSoundPool = null;
		}
	}	

	/**
	 * The initial value is used to set 75% of the devices max value. It could be used
	 * needed to put a start sound level at the beginning of the interaction.
	 */
	private void setInitialDeviceSound() {
		int volume = (int) (0.75*mAudioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC));
		mAudioManager.setStreamVolume(AudioManager.STREAM_MUSIC, volume, 0);		
	}

	/**
	 * Initialize the SoundPool and loads the resources into memory. Each sound is
	 * related to the current zone/enum of where the user is pointing at.
	 */
	private void initSoundPoolMap(){
		
		mSoundPoolMap.put(SoundID.AHEAD_STATE, mSoundPool.load(context, getResId("raw", "scanner13"), 0));
		
		mSoundPoolMap.put(SoundID.ZONE_ONE_STATE, mSoundPool.load(context, getResId("raw", "scanner12"), 0));		
		mSoundPoolMap.put(SoundID.ZONE_TWO_STATE, mSoundPool.load(context, getResId("raw", "scanner10"), 0));		
		mSoundPoolMap.put(SoundID.ZONE_THREE_STATE, mSoundPool.load(context, getResId("raw", "scanner8"), 0));		
		mSoundPoolMap.put(SoundID.ZONE_FOUR_STATE, mSoundPool.load(context, getResId("raw", "scanner6"), 0));		
		mSoundPoolMap.put(SoundID.ZONE_FIVE_STATE, mSoundPool.load(context, getResId("raw", "scanner4"), 0));		
		mSoundPoolMap.put(SoundID.ZONE_SIX_STATE, mSoundPool.load(context, getResId("raw", "scanner2"), 0));
		
		mSoundPoolMap.put(SoundID.BACKSIDE_STATE, mSoundPool.load(context, getResId("raw", "scanner0"), 0));

		mSoundPool.setOnLoadCompleteListener(new OnLoadCompleteListener() {

			public void onLoadComplete(SoundPool soundPool, int sampleId, int status) {
				soundPoolReady = true;
				if((mSoundPoolMap.size() == sampleId) && mediaPlayerReady){
					onPrepared(soundPoolReady);
				}
			}
		});
	}
	/**
	 * Returns the resource id from the manager. Works inter-project unlike R.res
	 * @param res The ressource name as string
	 * @return
	 */
	private int getResId(String type, String res) {
		return context.getResources().getIdentifier(String.format("org.haptimap:%s/%s", type, res), null, null);
	}

	/**
	 * Used to initialize the EventHandler used to interact with the {@link AudioGuideEventListener} 
	 */
	private void initEventHandler() {
		Looper looper;
		if ((looper = Looper.myLooper()) != null) {
			mEventHandler = new EventHandler(looper);
		} else if ((looper = Looper.getMainLooper()) != null) {
			mEventHandler = new EventHandler(looper);
		} else {
			mEventHandler = null;
		}
	}

	/**
	 * Initiates the MediaPlayer, containing the tune used when the user arrives to a destination
	 * @return whether the player was successfully loaded and initlialized.
	 */
	private boolean initMediaPlayer(){
		this.mMediaPlayer = MediaPlayer.create(context, getResId("raw", "burundi_beat"));
		return (mMediaPlayer != null) ? true : false;
	}

	@Override
	protected synchronized void performAction(){

		mDeviation = Math.abs(validate(mDeviation));

		if((mDeviation <= AHEAD_DEVIATION_LIMIT)){
			mCurrentState = SoundID.AHEAD_STATE;
			//			if(DEBUG) Log.d(TAG, "State: AHEAD_STATE at " + mDeviation);
		} else if (mDeviation >= OUTSIDE_GEIGER_LIMIT){
			mCurrentState = SoundID.BACKSIDE_STATE;
			//			if(DEBUG) Log.d(TAG, "OUTSIDE_GEIGER_LIMIT at " + mDeviation);
		} else {
			if(mDeviation < ZONE_ONE_LIMIT){
				mCurrentState = SoundID.ZONE_ONE_STATE;
				//				if(DEBUG) Log.d(TAG, "ZONE_ONE_LIMIT at " + mDeviation);
			} else if (mDeviation < ZONE_TWO_LIMIT){
				mCurrentState = SoundID.ZONE_TWO_STATE;
				//				if(DEBUG) Log.d(TAG, "ZONE_TWO_LIMIT at " + mDeviation);
			} else if (mDeviation < ZONE_THREE_LIMIT){
				mCurrentState = SoundID.ZONE_THREE_STATE;
				//				if(DEBUG) Log.d(TAG, "ZONE_THREE_LIMIT at " + mDeviation);
			} else if (mDeviation < ZONE_FOUR_LIMIT){
				mCurrentState = SoundID.ZONE_FOUR_STATE;
				//				if(DEBUG) Log.d(TAG, "ZONE_FOUR_LIMIT at " + mDeviation);
			} else if (mDeviation < ZONE_FIVE_LIMIT) {
				mCurrentState = SoundID.ZONE_FIVE_STATE;
				//				if(DEBUG) Log.d(TAG, "ZONE_FIVE_LIMIT at " + mDeviation);
			} else if (mDeviation < ZONE_SIX_LIMIT){
				mCurrentState = SoundID.ZONE_SIX_STATE;
				//				if(DEBUG) Log.d(TAG, "ZONE_SIX_LIMIT at " + mDeviation);
			}
		}		
		if(DEBUG) Log.d(TAG, "State: " + mCurrentState + " Deviation=" + mDeviation);
		playSound();
	}

	/**
	 * Play the sound mapped to the current deviation and check the volume values.
	 * The volume values are recalculated since it is necessary to check if the user
	 * has changed the volume on the device.
	 */
	private void playSound(){
		mActualVolume = mAudioManager.getStreamVolume(AudioManager.STREAM_MUSIC);
		mMaxVolume = mAudioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC);
		mVolume = mActualVolume / mMaxVolume;
		if(soundPoolReady){
			currentStreamID = mSoundPool.play(mSoundPoolMap.get(mCurrentState), mVolume, mVolume, 1, 0, 1f);	
		}			
		return;
	}

	/**
	 * @param value The current deviation/value to be rechecked. If the value is of the format 0 < bearing < 360
	 * then it will reformatted into -180 < bearing < +180 
	 * @return A valid value used in this class
	 */
	private synchronized float validate(float value){
		if(value > 180){
			value = (360-value)%180;
		}
		return value;
	}

	// EVENTS called on different situations using the EventHandler.
	private void onPrepared(boolean isReady){
		Message msg = Message.obtain();
		msg.what = ON_PREPARED;
		msg.obj = isReady;
		mEventHandler.sendMessage(msg);
	}

	@Override
	protected void onDestinationReached(){
		Message msg = Message.obtain();
		msg.what = ON_DESTINATION_REACHED;
		mEventHandler.sendMessage(msg);
	}

	@Override
	protected void onRateIntervalChanged(long millis){
		Message msg = Message.obtain();
		msg.what = ON_RATE_INTERVAL_CHANGED;
		msg.obj = millis;
		mEventHandler.sendMessage(msg);
	}

	/**
	 * Help class used to handle the events when a listener is registered.
	 *
	 */
	private class EventHandler extends Handler {

		public EventHandler(Looper looper) {
			super(looper);
		}

		@Override
		public void handleMessage(Message msg) {
			switch(msg.what) {
			case ON_PREPARED:
				if(mAudioGuideEventListener != null){
					mAudioGuideEventListener.onPrepared((Boolean) msg.obj);
				}
				break;

			case ON_DESTINATION_REACHED:
				AudioGuide.this.onPause();
				stopSoundPool();
				if(mAudioGuideEventListener != null){
					mAudioGuideEventListener.onDestinationReached();
				}
				context.sendBroadcast(new Intent(ACTION_DESTINATION_REACHED));
				if(mMediaPlayer != null){
					mMediaPlayer.start();
				}
				break;

			case ON_RATE_INTERVAL_CHANGED:
				if(mAudioGuideEventListener != null){
					mAudioGuideEventListener.onRateIntervalChanged((Long) msg.obj);
				}
				break;

			default:
				Log.e(TAG, "Unknown message type " + msg.what);
				return;
			}
		}
	}

	/**
	 * Registers a {@link AudioGuideEventListener} listener which is called on various events.
	 * 
	 * @param mAudioGuideEventListener The listener that will be registered for callbacks
	 * 
	 * @throws IllegalArgumentException if the listener is null
	 * 
	 * @see {@link AudioGuideEventListener}
	 */
	public void registerAudioGuideEventListener(AudioGuideEventListener mAudioGuideEventListener){
		if(mAudioGuideEventListener == null){
			throw new IllegalArgumentException("mAudioGuideEventListener==null"); 
		}
		this.mAudioGuideEventListener = mAudioGuideEventListener;
	}

	/**
	 * Unregister the {@link AudioGuideEventListener} listener, if it has been registered before. No
	 * more callbacks will be sent/received.
	 * 
	 * @see {@link #registerAudioGuideEventListener(AudioGuideEventListener)}
	 */
	public void unregisterAudioGuideEventListener(){
		this.mAudioGuideEventListener = null;
	}

	@Override
	public void setNextDestination(String destinationName, double[] latlon) {
		super.setNextDestination(destinationName, latlon);
		this.onResume();
	}

	@Override
	public void setNextDestination(String destinationName, Location destination) {
		super.setNextDestination(destinationName, destination);
		this.onResume();
	}

	@Override
	public void setNextDestination(String destinationName, String latitude,
			String longitude) {
		super.setNextDestination(destinationName, latitude, longitude);
		this.onResume();
	}

	@Override
	public void setNextDestination(WayPoint destination) {
		super.setNextDestination(destination);
		this.onResume();
	}
}
